ReconnectSupervisor swallowed a transport failure reported while Replaying,
so a restarted event stream refused/ended a round trip after the replay
began still let the replay go Healthy over a dead stream (and the next
advise's re-report restarted at attempt 1 with no backoff). A failure
reported during Replaying is now remembered and turns that attempt's
success into a retry with backoff. ReplayAsync's synchronous dead-pump
throw yields to a teardown's cancellation.
Claude-Session: https://claude.ai/code/session_01GT67AD99zQQqpLCVm3p7xk
- ReplayAsync fails when its restarted pump's stream has already ended, so
the supervisor backs off and stays Degraded instead of going Healthy over
a dead stream (whose next advise re-report restarted at attempt 1 with no
backoff - a tight reconnect loop).
- ReconnectSupervisor.AttemptInProgress (reopen through replay); advises
wait for a stale pump's replacement only while an attempt is running, not
for a whole backoff (the state still reads Reopening/Replaying there).
- ReconnectSupervisor: _disposed is set under the state lock and re-checked
inside ReportTransportFailure, so a late report cannot start a recovery
loop that StopAsync already missed.
- EventPump.HasEnded is a flag set before StreamRequested is released, and
a stream that ends/faults on its first MoveNext is marked ended first.
- SubscriptionRegistry fast path: a miss now falls through to the locked
path (lookup + session + window check atomically) instead of a lock-free
re-lookup.
- Tests: replay over an immediately-ending stream fails; AttemptInProgress
across attempt/backoff; ReportTransportFailure after stop; the
reopen-window test uses a generous wait bound.
Claude-Session: https://claude.ai/code/session_01GT67AD99zQQqpLCVm3p7xk
- StreamRequestWait 1 s (under Tier-A Read 2 s / Subscribe 5 s budgets) and
a stale pump is only waited on while the supervisor is reopening or
replaying (or a restart is underway); in a reconnect backoff the advise
proceeds at once. The no-stream log is a rate-limited Warning while
Healthy, Debug otherwise.
- A current-session pump whose loop has ended is not a stream: if the
supervisor already reports Healthy (its fault was swallowed while it was
recovering) the failure is re-reported to start a new cycle.
- Subscribe/Read no longer start the pump directly (only
AwaitCurrentEventStreamAsync does, honouring the replay restart flag);
the teardown refusal is an explicit ThrowIfRuntimeStopping.
- SubscriptionRegistry.ResolveOrPark fast path: the handle lookup and the
open-window check were not atomic, so a subscribe that registered the
handle and closed its window in between got its initial value dropped as
stale (found by a stress run: the first event of a bulk advise lost). It
re-looks-up before declaring stale. Deterministic test via a seam.
- Tests: first Read on a fresh driver, replay of a data subscription.
Claude-Session: https://claude.ai/code/session_01GT67AD99zQQqpLCVm3p7xk
Round 1 replaced a stale-session pump from any advise path and disposed the
old one in the background; that could start a second stream while the old
one still held the gateway's single-subscriber slot, and a kept
current-session pump could be dead with no fault re-reported.
- EnsureEventPumpStarted reuses again; only ReplayAsync restarts the pump.
RestartEventPumpForReplay keeps a current-session pump only while its loop
is running (EventPump.HasEnded), and flags the restart so no advise path
starts a competing pump in the dispose gap.
- AwaitCurrentEventStreamAsync (bounded by StreamRequestWait) starts a pump
only when none exists, WAITS for the replay while the pump is stale or
restarting, waits for StreamRequested, and returns the session it verified.
SubscribeAsync (per #861 attempt), read-via-subscribe and the probe hook
issue and fence the advise under that session, so a reconnect after the
check is caught as a straddle and retried.
- Read: a deadline (not caller) cancel during the wait falls through to
per-ref results.
- Tests: reopen-window probe test now asserts the advise waits for the
replay's single new stream; first Subscribe/Read wait for the stream; the
fencing tests that advise in the window use a short wait bound; the
production-path seed test uses an empty galaxy (a real probe advise now
starts a stream against the fake endpoint).
Claude-Session: https://claude.ai/code/session_01GT67AD99zQQqpLCVm3p7xk
- EnsureEventPumpStarted replaces a pump created for an older registry
session (the reopen -> replay window still held the dead session's pump,
so an advise there lost its initial value and the replay skipped it);
RestartEventPumpForReplay keeps a pump already on the current session.
- SubscribeAsync, read-via-subscribe and ReplayAsync also await the
bounded StreamRequested wait before advising, not just the probes.
- TryEnsureEventPumpStarted tolerates a teardown flipping _runtimeStopping
mid-check; the probe hook honours an already-cancelled seed token.
- StreamRequested is documented as narrowing, not closing, the attach race.
- Tests: reopen-window probe test, EventPump.StreamRequested tests, and the
FixRound1 fresh-pump assertion now waits on a positive control.
Claude-Session: https://claude.ai/code/session_01GT67AD99zQQqpLCVm3p7xk
The gateway's StreamEvents with after_worker_sequence = 0 is a fresh stream
and never replays, and the event pump started only on the first
Read/Subscribe, so platform ScanState probes advised before then lost their
initial value (and a driver with no authored tags never got any host status).
PerPlatformProbeWatcher now awaits an ensure-event-stream hook before every
probe SubscribeBulk (seed, discover sync, reconnect ResubscribeStaleAsync).
The driver's hook starts the pump (refused while a teardown is in progress)
and waits, bounded, on the new EventPump.StreamRequested signal - completed
once the stream loop's first MoveNext has issued the StreamEvents RPC, or
when the loop ends - so the advise never precedes the stream request.
Claude-Session: https://claude.ai/code/session_01GT67AD99zQQqpLCVm3p7xk
The gateway's shared GalaxyHierarchyProjector ANDs template_chain_contains
entries, so the single request carrying both $WinPlatform and $AppEngine
matched no object and the probe watcher always seeded 0 platforms.
GatewayGalaxyPlatformSource now runs one paged sweep per template and
unions/dedupes by tag name. The tests use a fake gateway that applies the
projector's AND semantics (they fail on the old single request).
Claude-Session: https://claude.ai/code/session_01GT67AD99zQQqpLCVm3p7xk
With Reconnect.ReplayOnSessionLost=false a recreated gateway session carries none of
the old advises, so every pre-reconnect subscription went silently deaf: nodes kept
their last value as Good and health returned to Healthy (only a Warning log).
ReplayAsync now records the stranded subscriptions and publishes BadNoCommunication
(StatusCodeMap.BadNoCommunication, 0x80310000; value null — no driver value cache)
for every tag of each, straight to OnDataChange so LastSuccessfulRead is not stamped.
GetHealth reports Degraded naming the setting while any stranded subscription is
still registered; it clears on UnsubscribeAsync (the host's re-subscribe path) or
runtime teardown, and pruned ids never pin it. A subscribe after the reconnect is an
ordinary new-session subscription. With nothing else flowing, the comms-loss
watchdog re-initializes the driver after 90 s (a delayed replay).
Tests: Runtime/StrandedSubscriptionTests (6). Docs: drivers/Galaxy.md, which also
no longer claims the replay uses the gateway's ReplaySubscriptions RPC.
Claude-Session: https://claude.ai/code/session_01GT67AD99zQQqpLCVm3p7xk
BLOCKER: the reconnect replay chose work from the registry entries' sessions,
but ONE registry entry holds every probe handle and any probe publish on the
new session (a seed in the reopen→replay gap, or the straddle retry) re-stamps
it as current — the replay then skipped the probe watcher and the old
platforms stayed bound to handle 0 (host status frozen); a publish after the
snapshot made it re-subscribe everything (double advise, leaked handle).
ReplayAsync now excludes the probe entry from its snapshot and calls the new
PerPlatformProbeWatcher.ResubscribeStaleAsync on every reconnect, which
re-subscribes exactly the platforms whose ProbeHandle.Session predates the
current session. The replay-gap path keeps ResubscribeAsync (all platforms).
Also: ClearRegistry (teardown) starts a new registry session, so a late
PurgeParked from a teardown-orphaned call cannot reach the next runtime's
parked values; the read path's unused pump local is gone (call kept).
Tests go through the driver's reopen/replay: the probe old-session-number test
is rewritten that way, plus the seed-in-the-gap scenario (only P and Q are
re-subscribed, R keeps its new-session handle, all three resolve). Both checked
red with the probe replay removed / re-subscribing all platforms.
Refs #859, #861.
Claude-Session: https://claude.ai/code/session_01GT67AD99zQQqpLCVm3p7xk
B-1: the subscribe-once read's cleanup unsubscribes what the registry holds at
removal (the replay may have rebound it to new-session handles), not its
original bindings; nothing when the entry still holds dead old-session
handles. Found while testing it: reads were attached to the pump INSTANCE, so
a read across a reconnect's pump restart never saw the replayed value — reads
now listen on a driver-level ReadDataChange raised for whichever pump is live.
I-A: PerPlatformProbeWatcher records the registry session with each probe
handle. Publishing goes through the new fenced TryUpsert and binds only
current-session handles; removal/stop unsubscribe and purge only those; a
seed reply that straddled a reconnect records nothing and is re-issued (max 3).
I-B: PurgeParked takes the session captured BEFORE the unsubscribe RPC and is
a no-op under the park gate if the session changed.
I-C: ReopenAsync starts a new registry session before AND after the gateway
session swap; ReplayAsync does so only when no reopen ran (injected subscriber
/ tests) and replays only subscriptions still holding older-session handles,
so a subscribe issued after the swap is live and not double-advised.
P-1: ReplayAsync restarts the pump before any early return (no entries used to
leave the dead pump in place). P-2: ReplayOnSessionLost=false logs a Warning;
misleading comment about gateway-side replay fixed.
M-a: TryRebind returns BindOutcome; UnknownId releases the handles just
advised for a subscription removed mid-replay. M-b: the replay/re-snapshot
pass reads the session once and stops if it changes. M-c: parked entries drop
their MxEvent when they leave the parked set. M-d (retry a straddled read)
not done.
Tests (TestWait) for each, each checked red against a mutation of its fix.
Refs #859, #861.
Claude-Session: https://claude.ai/code/session_01GT67AD99zQQqpLCVm3p7xk
I-2: unclaimed parked events are trimmed by window sequence. Each window gets a
monotonically increasing sequence at Begin, each parked event is stamped with
the newest one, and on every End events stamped before the oldest open
window's sequence are dropped (amortised O(1) via a park-order queue). Under
concurrent reads the open count rarely hit 0, so trailing events used to pile
up to the bound and push genuine early values into overflow.
I-1 / #861: SubscriptionRegistry.StartNewSession() — called FIRST in
ReplayAsync on every reconnect — clears held events, invalidates the reverse
map (subscriptions kept) and bumps a session number. New-session events whose
number an old binding still held now park until the replay's per-subscription
Rebind promotes them, instead of resolving to the wrong tag. TryRegister /
TryRebind refuse a stale session atomically under the park gate: a
SubscribeAsync whose reply straddled a reconnect is re-issued on the new
session (max 3 attempts), a subscribe-once read returns BadCommunicationError,
ResubscribeAllAsync stops if the session changes again. Old-session handle
numbers are never unsubscribed (Remove reports whether the entry belongs to
the current session; the read's late-reply path checks too), and an old
session's pump can no longer resolve through the map (fast path checks the
session).
I-3 (interim): a completed UnsubscribeBulk purges events parked for its
handles (driver + probe watcher). Full fix needs a worker sequence on the
SubscribeBulk reply (mxaccessgw, optional) — documented.
M-1: a resolved handle with a stranded parked list promotes it and resolves
the live event (never parks it). M-2: ResolveOrPark returns the ready events
to replay first, one gate acquisition per event while anything is held.
M-3: SubscribeAsync/read dispose the window before the orphan-release RPC.
M-5: ResolveOrPark remarks document the single-dispatching-pump invariant.
Tests (TestWait): overlapping windows don't accumulate, purge, stranded list,
session fence + map invalidation, stale-session registration refused,
end-to-end replay with swapped handle numbers and event-before-reply,
subscribe and read straddling a reconnect, read deadline closes its window.
Each was checked red against a mutation of the fix it pins.
Refs #859, #861.
Claude-Session: https://claude.ai/code/session_01GT67AD99zQQqpLCVm3p7xk
1. ReadAsync (subscribe-once) now opens the early-event window around its
SubscribeBulk + registration; an initial value that overtook the reply was
dropped and the read returned BadTimeout.
2. Register/Rebind write the reverse map and promote parked events in ONE
critical section under the park gate, and ResolveOrPark resolves under that
gate whenever anything is held (lock-free fast path only when nothing is),
so a later live event can no longer overtake the parked initial value. A
resolved handle that still holds a parked list queues behind it.
3. Held events are fenced by a session epoch: each new EventPump calls
StartNewEpoch(), discarding parked AND promoted-but-unreplayed events; an
older pump can neither park nor replay. Item-handle reuse across a reconnect
can no longer put an old-session value on a new tag.
4. ResubscribeAllAsync opens one window per subscription, not across the whole
replay.
5. Promoted-but-unreplayed (ready) events count toward MaxParkedEvents.
6. Begin/End of a window run under the park gate, so N->0 discard and 0->1
open are atomic (no inherited leftovers).
Documented that ParkedEventsReady may run under _registryGate/_syncLock and
relies on the channel's AllowSynchronousContinuations=false.
Tests use TestWait (#858); each new test was checked red against a mutation
of the fix it pins.
Claude-Session: https://claude.ai/code/session_01GT67AD99zQQqpLCVm3p7xk
mxaccessgw gives no reply-before-event ordering: the worker completes the STA
command via a RunContinuationsAsynchronously TCS, so the reply is framed on a
thread-pool continuation while the STA already pumps the OnDataChange Advise
queued (drained/framed by a separate loop), and the reply (unary Invoke) and
the event (StreamEvents) are separate HTTP/2 streams. The driver then also
registers handles concurrently with the pump's dispatch loop. MXAccess's
initial value could therefore be dropped as "stale after unsubscribe",
leaving the node at its pre-subscribe value until the next change.
SubscriptionRegistry gains an early-event window: every SubscribeBulk call
site (SubscribeAsync, ResubscribeAllAsync, PerPlatformProbeWatcher) opens
BeginPendingSubscribe() before the RPC and closes it after registering.
Inside the window unknown-handle events are parked (bounded, 100k); the
Register/Rebind that binds a handle promotes them, and the EventPump replays
them on its single dispatch thread before any later event for that handle
(woken by a sentinel when no further event arrives). Closing the last window
discards unclaimed events, so the stale drop is unchanged outside it.
New counters galaxy.events.early_{parked,replayed,overflow}.
Claude-Session: https://claude.ai/code/session_01GT67AD99zQQqpLCVm3p7xk
Under heavy parallel load (14 concurrent test processes + 14 CPU burners, with
DOTNET_ThreadPool_ForceMinWorkerThreads=1 as the reproducer) the Galaxy tests
with 1-3 s fixed waits failed 16/70 runs. Every failure was a background loop
started with Task.Run (alarm feed, DeployWatcher, EventPump, ReconnectSupervisor)
not being SCHEDULED within the ceiling - e.g. DeployWatcher's stream had not even
been opened after 2 s. No production race: the code under test was checked for
each (alarm feed dispatch is synchronous in-order, DeployWatcher/ReconnectSupervisor
state is lock- or loop-local, subscribe registers before returning).
- New shared TestWait (UntilAsync / ForAsync / HealthyAsync): 30 s presence
ceiling, failure reports elapsed ms + an observed-state snapshot (#856 shape;
GalaxyDriverFixRound1Tests.WaitUntilAsync now delegates to it).
- Converted the short waits in GatewayGalaxyAlarmFeedTests, DeployWatcherTests,
GalaxyDriverSubscribeTests, GalaxyDriverRawPathKeyingTests (its WaitForAsync
never threw on timeout), EventPumpStreamFaultTests, EventPumpReplayGapTests,
ReconnectSupervisorTests; FixRound2 InitBudget 2 s -> 10 s (the parked fetch
never completes, so any finite bound keeps the verdict).
- Absence by short sleep replaced with positive controls: DeployWatcher bootstrap
and same-deploy-time (follow with a real change, assert exactly that one raise -
verified red when suppression is removed), subscribe failed-tag / unsubscribed
handle (follow with a live handle's event), faulted pump (wait for its
onStreamFault instead of 100 ms).
- Fixed sleeps that raced scheduling: subscribe-once read tests wait for the
read's registration (TrackedSubscriptionCountForTests) instead of 50 ms;
supervisor Dispose test waits for reopen entry instead of 50 ms; the
already-Healthy WaitForHealthy test asserts synchronous completion instead of a
wall-clock bound. FakeSubscriber.Map is now a ConcurrentDictionary.
Loaded run (14 parallel x 5, 14 burners, ForceMinWorkerThreads=1):
before 16/70 failed runs; after 0/350 (70 + 210 + 70). Without the thread-pool
override: 0/70 before and after on this host. Serial: 472 passed, 8 skipped.
Claude-Session: https://claude.ai/code/session_01GT67AD99zQQqpLCVm3p7xk
Found while load-verifying #856: ShutdownAsync_cancels_the_event_stream_and_releases_the_pump
timed out 7/700 under parallel load. The fake's StreamCancelled was set by a
CancellationToken registration scoped by `using` inside the async iterator.
On Cancel() the newer ReadAllAsync registration runs first and resumes the
iterator on a pool thread, whose `using` can dispose our registration while
Cancel() is still walking the callback list - so it never runs. Signal from
the iterator's finally (when the token is cancelled) instead, which also
proves the stream actually ended. 0/700 after.
Claude-Session: https://claude.ai/code/session_01GT67AD99zQQqpLCVm3p7xk
PerPlatformProbeWatcher records a platform's handle and publishes the probe
binding into the driver registry as one step under _syncLock, but
WatchedPlatforms read the handle map lock-free - so it could report a
platform one step ahead of the registry Upsert. A ScanState event raised in
that window was dispatched by the EventPump, missed ResolveSubscribers and
was dropped as "stale after unsubscribe", leaving the host status empty.
That is the #856 flake: A_fetch_completing_after_init_populates_host_statuses
waited for WatchedPlatforms, emitted ScanState, and timed out at 10 s on the
final Running wait. Reproduced 11/700 (28 parallel runners x 25 + 14 CPU
burners), every failure at that step with the binding present by the time of
diagnosis and hostStatuses=[] - i.e. the event was dropped. 0/1400 after.
WatchedPlatforms now reads under _syncLock (test seam only; the dispatch path
already checks the map after the registry, the safe order). New
PerPlatformProbeWatcherVisibilityTests spins a reader on another thread and
asserts the registry entry exists the moment the platform is visible
(old code: torn in 1691/2000 iterations). WaitUntilAsync gains a describe
overload; the #856 test's waits now report elapsed time and the observed
seed/binding/pump/host-status chain on timeout.
Claude-Session: https://claude.ai/code/session_01GT67AD99zQQqpLCVm3p7xk
- AdminUI MTConnect driver form: "Comms-loss confirm (ms)" in the Reconnect section, always written
(0 = first refusal, negative = off is never dropped), case-insensitive load, unknown keys preserved,
advisory at >= 90000 ms (the watchdog fires first). Form output byte-pinned and parsed through
MTConnectDriver.ParseOptions on the driver side.
- Driver: a confirmed refused-connection outage now annotates every told CONDITION quality-only
BadNoCommunication (state/ack kept, once per outage, UNAVAILABLE-Bad skipped); the first delivery
afterwards re-diffs all subscribed conditions so unchanged ones get a quality-only Good.
- docker-dev/docker-compose.mtconnect-gate.yml: throwaway Agent in its adapter's network namespace so a
stop is a genuine ConnectionRefused (a plain stopped container is a DNS failure = ambiguous).
- Docs: MTConnect.md comms-loss section + live-gate recipe, docker-dev README, drivers.md rule.
Refs #852
I-1: Test Connect / browse configs may carry unsaved edits (the device modal merges
the stored DriverConfig with the typed device config). The server
(DriverInstanceIdentities.EffectiveOwnerAsync, used by AdminOperationsActor and
BrowserSessionService) honours the named owner only when every endpoint value
(new DriverCredentialRules.EndpointFields: Galaxy Endpoint, OpcUaClient
EndpointUrl/EndpointUrls/DiscoveryUrl, Heidenhain Host/Port) is one that driver
stores; otherwise the owner is null and its instance-named secrets are refused.
Every Test Connect / browse whose config carries an in-scope secret:/env: reference
is recorded (CredentialUseAudit: IAuditWriter event category CredentialUse + a
structured Information line: user, driver, endpoint hosts, outcome).
L-1: the UserCertificatePath rule is skipped only in an object that explicitly sets
AuthType to non-Certificate (string or ordinal); the deploy gate also checks the
runtime-merged driver+device config so the switch cannot be split from the path; the
form no longer persists the hidden field.
L-2: a ConfigDb fault in the browse check is a refusal; RawBrowseModal catches open
failures into its error message.
L-4: a relative UserCertificateDirectory resolves against AppContext.BaseDirectory.
Co-Authored-By: Claude <noreply@anthropic.com>
#835: inside one driver namespace, a secret:<ns>/<X>/... reference whose first
segment names ANOTHER existing same-type driver instance (id or name,
case-insensitive; another-instance match beats an own-name match, so renaming
one's driver after a victim's unlocks nothing) is refused at save
(DriverConfigSaveGate.ValidateAsync), deploy (CredentialReferenceOtherInstance,
Heidenhain included), Test Connect (TestDriverConnect.DriverInstanceId, ConfigDb
read off the mailbox) and live browse (BrowserSessionService, fail-closed without
a ConfigDb). One rule: DriverCredentialPolicy.DescribeOtherInstanceProblems +
DriverInstanceIdentities. Shared names and the driver's own id stay valid; env:
is not instance-scoped. Running drivers are unchanged (warn-only window: the
deploy gate and the upgrade preflight / /raw banner list them).
#836: OpcUaClient UserCertificatePath is a relative path inside
Drivers:OpcUaClient:UserCertificateDirectory (default
<LocalAppData>/OtOpcUa/pki/user-certificates). Syntactic rule (CredentialFilePaths,
a DriverCredentialPolicy path field) at every #815 seam; at load time
ResolveSecretRefsAsync joins, canonicalises and requires lexical AND
link-resolved containment before the PFX password is resolved, and
LoadCertificate refuses an unresolved path. A running driver's pre-#836 absolute
path keeps loading with a rate-limited warning (LegacyCredentialReferenceTracker.ReportPath).
Co-Authored-By: Claude <noreply@anthropic.com>
#820 served DurationGood/DurationBad (ms) on SqlServer/QuestDB but only
QualityMixConformance asserted them. The shared Processed suite now covers
them like the percentages: hand values over tag A (20000/10000/20000/BadNoData
and 0/10000/0) with statuses + timestamps and Duration = Percent x interval;
the SDK-calculator parity theory (forward/reverse/truncated); outage markers;
the only-Bad interval; string tags; and the time-weighted row cap.
Live: SqlServer.IntegrationTests 211/211 (central SQL), QuestDb.IntegrationTests
249 passed / 1 skipped (shared /opt/otopcua-questdb fixture).
Co-Authored-By: Claude <noreply@anthropic.com>
- A /sample or re-baseline failure whose chain holds SocketError.ConnectionRefused, unbroken for
CommsLossConfirmMs (new, default 2000; 0 = first refusal; negative = off), publishes every subscribed
value BadNoCommunication once per outage; the first delivery afterwards republishes all of them from
the index (unchanged values included). Timeouts/resets/DNS/pause keep the #527 watchdog contract.
- SampleCountCeiling clamps the adaptive raise to the /probe bufferSize (never below SampleCount).
- Docs: MTConnect.md comms-loss section + config key; drivers.md rule.
Refs #846
User decision: save, Test Connect, live browse and the deploy gate keep
refusing out-of-namespace credential references, but an ALREADY-DEPLOYED
driver must not fault at the upgrade restart or (Galaxy) at the next
gateway reconnect, since ReopenAsync re-resolves Gateway.ApiKeySecretRef.
- LegacyCredentialReferenceTracker (Core.Abstractions): the one, deprecated,
call-site-chosen compatibility path. Running Galaxy (InitializeAsync,
ReopenAsync, ack fallback) and OpcUaClient (session open) pass it and keep
the pre-#815 semantics (case-insensitive prefixes, Galaxy file:, OpcUaClient
literal pass-through) with a rate-limited Warning (driver/field/allowed
form, never the value) and a credential-compat:<field> diagnostics entry
(both drivers now implement IDriverDiagnostics). Browsers and probes pass
none and stay hard-fail; Heidenhain has none.
- Deploy-gate message names the migration.
- Upgrade preflight: CredentialReferencePreflightService (admin nodes, once
after ApplicationStarted, ConfigDb only, resolves nothing) logs a Warning
per Draft row / Deployed-artifact finding; the /raw banner shows them plus
Running drivers reporting warn-only use (driver-health diagnostics).
- docs/security.md: exact replacement table (incl. the old form default
env:MX_API_KEY, file:, un-namespaced secret:, casing changes), the
ReopenAsync reconnect behaviour, the warn-only window and its removal.
Co-Authored-By: Claude <noreply@anthropic.com>
Comms_loss_invalidates_every_remote_value_and_recovery_republishes_identical_values failed
1/40 on base 5bbe1e3c (and 1/29 on this branch): "Capabilities/Identity should be
CommunicationLost but was Supported". RetireAll -> Invalidate publishes every tag in authored
order under one lock, capability LAST, and the recorder sees each publish as it happens; the
test asserted the capability the instant the five value tags read BadNoCommunication, i.e.
before the pass's final publish. Now the wait includes the capability's CommunicationLost
(a presence the pass must produce) and additionally asserts no value went Good again during
the outage.
Second latent race fixed in the same test: Degraded holds through Backoff AND Connecting, but
the host status is Stopped only in Backoff (Unknown while a reconnect attempt is in flight),
so the test now waits for Degraded && Stopped instead of sampling whichever phase it hit.
After the fix: 79/80 filtered runs passed and 10/10 full-suite runs; the one non-pass was a vstest
"failed to connect to testhost after 90 s" host-startup timeout (the test never ran), not a test result.
SDK 2.0.1 checks the server host name in TWO places: Session.OpenAsync (after
the chain is trusted) and DefaultSessionFactory.CreateChannelAsync, against the
certificate in the GetEndpoints reply and before any security-mode test. opc-plc
sends a certificate on its None endpoint, so `connect -u
opc.tcp://10.100.0.35:50000 --validate-host-name` was refused on an
unauthenticated channel, where a host check proves nothing.
All three call sites now pass checkDomain only when the selected endpoint's
SecurityMode != None: OpcUaEndpointSelection.CheckDomainFor for the driver
(OpenSessionOnEndpointAsync, so the failover sweep too) and the AdminUI browser;
the same expression in Client.Shared ConnectToEndpointAsync (shared by
failover). New live tests: driver None + flag on vs :50000 reads; browser None +
flag on vs the NAT fixture browses; the secure + flag refusals still hold.
Docs: name both check sites; a zone-qualified IPv6 host never matches (FindDomain
compares the zone too), so link-local needs validateServerHostName=false; the
match is loose (first label alone, subject DC= parts count, no wildcards), which
caveats the CA recommendation.
Live gate against the cppagent 2.7 fixture: count=16384 with bufferSize
16384 streams; 16385 is OUT_OF_RANGE ('count' must be less than 16385).
Warn only when SampleCount > bufferSize, pin both sides of the edge
against the live Agent, and correct the #804 wording that said 'at or
above'.
- ALARM: selectors (HeidenhainAlarmSelector, Contracts): ALARM:*, ALARM:OTHER,
ALARM:CLASS=<r>[/GROUP=<r>][/NUMBER=<r>] (<r> = n | lo..hi; negative numbers allowed).
An ALARM: address and the cross-driver alarm object only work together — the factory
refuses either alone, so editor, save path, CSV and deploy inspector agree. Data type
fixed Boolean; never a value (ReadAsync BadNotSupported), never in a data subscription.
- HeidenhainDriver implements IAlarmSource: one condition per ALARM: tag at its RawPath,
driven by the SAME allowlisted R_RI 27/28 walk on the control's single loop, on the new
Polling.AlarmMs cadence (default 2000 ms, only for controls carrying an ALARM: tag).
FOCAS #647 transition rules (Raise / Retrigger / Clear), identity = class+group+number+text,
deterministic display order, told-record on the driver instance, recorded only while a
handler listens. A failed walk (refusal, overflow, comms loss) is quality-only, never a clear.
- Acknowledge is LOCAL-ONLY: AcknowledgeAsync is a no-op success (FOCAS/MTConnect posture);
Lsv2ReadOnlyPolicy is unchanged — no ack or write request exists.
- Opt-in per tag, no driver flag (FOCAS #647 / MTConnect #626 precedent); Alarms/* arrays unchanged.
- IRediscoverable: a reconnect whose R_VR identity (model / NC / PLC / options) differs from
the previous session raises OnRediscoveryNeeded once (advisory re-browse chip). No extra traffic.
- AdminUI: Heidenhain joins NativeAlarmModel's alarm drivers; the typed editor fixes ALARM:
tags at Boolean and seeds/drops the alarm object with the selector; driver form gains
"Alarm conditions (ms)". (HeidenhainDriverFormModel also carries the #830 credential-notice change.)
Tests: HeidenhainAlarmTests (grammar, pairing, rules, simulator e2e with the captured iTNC 530
alarm record, local-only ack = zero bytes sent, quality-only outage/refusal, rediscovery),
Runtime.Tests HeidenhainNativeAlarmEndToEndTests (real DriverHostActor condition), a
DraftValidator case, and the NativeAlarmDriverSetGuard now scans the Heidenhain driver.
#816 finding: the SDK checks that the server certificate names the host only
when a session is created with checkDomain:true. The driver, the AdminUI
browser and Client.Shared all called the 8-argument
DefaultSessionFactory.CreateAsync, which forwards checkDomain:false — so no
host-name check ever ran (why a trusted NAT cert for *.invalid browsed fine
over an IP in #813). When enabled the SDK (Session.OpenAsync ->
ValidateDomains -> FindDomain) compares ConfiguredEndpoint.EndpointUrl.IdnHost
— the CONFIGURED host, because OpcUaEndpointSelection/EndpointSelector
rewrote the advertised one — against the cert's DNS/IP SANs; secured
sessions only, and the auto-accept handler accepts a mismatch.
Policy: certificateValidation.validateServerHostName, tri-state. Unset =
OpcUaCertificateValidationOptions.DefaultValidateServerHostName = false, the
pre-#816 behaviour, so nothing changes on upgrade; true = validate the
configured host; false = explicit NAT/port-publish opt-out. Threaded into the
driver, the AdminUI browser (same JSON) and the AdminUI form (a select that
keeps "unset" unset), Client.Shared ConnectionSettings.ValidateServerHostName
and the CLI --validate-host-name. A driver refusal names the opt-out.
#818: the NAT fixture's /app/pki is on the opc-plc-nat-pki volume (verified:
same thumbprint across --force-recreate), its PUBLIC cert is committed as
Docker/nat/opc-plc-nat-server.der, and NatPublishedEndpointBrowseTests run
every secure browse on a throwaway PKI (internal browser seam): refusal with
nothing trusted, positive with the cert trusted (unset/false host check), and
a BadCertificateHostNameInvalid refusal with the check on. The trusted cert is
OPCUA_NAT_SERVER_CERT or the committed one (skip on thumbprint mismatch);
OPCUA_NAT_SERVER_CERT_TRUSTED is retired. CI exports its throwaway NAT
container's public cert. ServerHostNameValidationTests pin the driver against
the plain opc-plc. MIN_EXECUTED not raised (needs a hosted run): +6 in
v2-integration (440 -> 446), documented in scripts/ci/README.md.
The opc-plc image ships no netstat, so the old check left otopcua-opc-plc
permanently unhealthy. Same probe as the NAT fixture (27302314). Redeployed
to /opt/otopcua-opcuaclient on the shared docker host; reports healthy.
.NET's Uri parses a zone ([fe80::1%eth0]) but drops it from Host, Authority,
AbsoluteUri and ToString(); only DnsSafeHost/IdnHost carry it. The shared
OpcUaEndpointUrlRewriter rebuilt the URL through UriBuilder, so the zone was
lost and link-local endpoints were unroutable. The SDK dials
new Uri(EndpointUrl).IdnHost (TcpByteTransport -> DnsEndPoint), which keeps
it, so splicing the zone back into the string is enough. Kept verbatim
(%eth0, %4, or RFC 6874 %25eth0) so the session dials exactly what
GetEndpoints reached. Applies to the driver, the AdminUI browser and the
Client CLI/UI (all share the rewriter).
#822: HISTORIAN_SQL_TEST_CONNECTION example now targets the shared host
10.100.0.35,14330 with its committed dev sa password (OtOpcUaDev_2026!); the
docker-dev rig's own localhost,14330 password (OtOpcUa!Dev123) is called out as
a different instance. New labelled (project: lmxopcua) QuestDB fixture stack
tests/.../Historian.QuestDb.IntegrationTests/Docker/docker-compose.yml
(questdb/questdb:10.0.1, PG wire :8812, console :19000), deployed to
/opt/otopcua-questdb on the Docker host; documented in infra/README.md §2a,
docs/Historian.md, CLAUDE.md endpoints and the historian rules.
Also corrects infra/README's stale "only MQTT/NAT carry the label" note.
Docs for #820 (nineteen served aggregates, DurationGood/DurationBad in ms,
Gateway still refuses) and the #821 facts (Part 13 vs frozen live vs stock
SDK 2.0.1, both directions; decision open with #809).
Co-Authored-By: Claude <noreply@anthropic.com>
A Designer could point a Galaxy / OpcUaClient endpoint at a host they control,
set a credential field to any reference the node can resolve
(env:ConnectionStrings__ConfigDb, env:ServerHistorian__ApiKey, secret:<any>,
Galaxy file:<any path>) and click Test Connect / live browse; the node resolved
the value and sent it to that host.
One shared rule in Core.Abstractions (CredentialReferences +
DriverCredentialPolicy) generalises the #803 Heidenhain namespace:
- Galaxy: secret:galaxy/... | secret:otopcua/galaxy/... | env:OTOPCUA_GALAXY_... |
legacy env:GALAXY_MXGW_...; file: refused; dev:/literal API key unchanged.
- OpcUaClient: secret:opcuaclient/... | env:OTOPCUA_OPCUACLIENT_...; literals unchanged.
- Heidenhain adopts the shared parser/resolver (namespace unchanged).
Enforced at the AdminUI save path (DriverConfigSaveGate), the deploy gate
(CredentialReferenceDeployChecks, code CredentialReferenceOutOfScope), Test
Connect (AdminOperationsActor, before any probe), live browse
(BrowserSessionService, before any browser) and again at resolve time in every
driver/browser (fail closed, before any store/env read).
Co-Authored-By: Claude <noreply@anthropic.com>
#820: SqlServer + QuestDB now serve Part 13 DurationGood / DurationBad from the
same Good / Bad region totals TimeWeightedAggregator already computes for
PercentGood / PercentBad — reported in milliseconds (OPC UA Duration, the SDK's
unit), Uncertain time in neither (Mantis 0006166), same BadNoData / status /
Partial rules. AggregateFamilies.Served grows 17 -> 19, so i=11201 and the
up-front refusal follow automatically; Gateway still refuses both (explicit
served set, unchanged). Client.Shared AggregateType / mapper / CLI gain both.
The #796 oracle answers DurationGood through the Good-only subclass as well.
QualityMix conformance asserts both directly (Q and QP, hand values, ms, and
= PercentGood x interval), replaces the "refused up front" test, and adds
Duration* to the forward/reverse/truncated oracle comparisons.
#821 (facts only, no decision): SdkAggregateParityTests pins an Uncertain-first
late-start interval (QP over [0,20), both directions) against ours (Part 13
Total 163.96, TimeAverage 10.93), the frozen 1.5.378.182 calculators (188.96,
9.448), SDK 2.0.1's stock calculators (188.96, 9.448) and a Good-first control
(all 163.96); LiveAggregateLateStartFactsTests pins the same through the booted
server's runtime AggregateManager (live AggregateFilter) incl. DurationGood
15000 / PercentGood 75 there vs 8000 / 40 in HistoryRead (#809). QualityMix
adds the reverse late-start variant (reverse TW pin + reverse oracle windows).
Co-Authored-By: Claude <noreply@anthropic.com>
Parse the /probe Header bufferSize; MTConnectDriverProbe reads sampleCount
(default 1000) and answers Ok with a WARNING message (amber chip in the
Test Connect button); InitializeAsync logs the same Warning and exposes
agentBufferSize on the diagnostics bag. Live-checked against the shared
cppagent fixture (bufferSize 16384).
- Credential exfiltration (review 2): password references are scoped to
secret:heidenhain/<name> and env:OTOPCUA_HEIDENHAIN_<NAME> only. Enforced by
HeidenhainOptionsParser.TryParseCredentialReference (parser, probe, deploy gate) AND again at
resolve time in HeidenhainCredentials (fail closed without consulting the store or the
environment), so Test Connect can no longer send env:ConnectionStrings__ConfigDb or another
subsystem's secret to an arbitrary Host. The probe also refuses an unsendable password.
- Optional-role credentials (review 6): an unresolvable/NUL/non-Latin-1/>247-byte DNC or DATA
credential never sends a login and never fails the session; the reads needing that role are
BadConfigurationError (capability ConfigurationError) and INSPECT telemetry continues. A
policy refusal inside AcquireAsync is per-tag, not a session teardown.
- Start after DisposeAsync is a no-op (review 3); initial subscription push and Publish are
ordered under one lock (review 4); JSON-null sections are normalised and the acquisition loop
catches every fault (review 5); backoff resets only after a session published a poll batch,
with clamped delays (review 7).
Tests: HeidenhainReviewFixTests + namespace/normalisation tests in HeidenhainConfigTests;
each was confirmed red against the reverted fix.
- Lift an Agent's MTConnectError body under a non-2xx status (cppagent 2.x serves
OUT_OF_RANGE as HTTP 400, NO_DEVICE as 404) into the refusal-marked
InvalidDataException, reading at most 64 KiB. The 400 used to be a transport
fault, so the OUT_OF_RANGE re-baseline never ran against a real 2.x Agent.
- Run the instanceId restart test on every re-baseline snapshot: on 2.x a restart
is first seen there (the old cursor gets a 400), and was adopted silently.
- Report the stream Degraded after 3 consecutive re-baselines with no /sample
chunk (it read Healthy for ever); LastSuccessfulRead keeps advancing.
- Prohibit DTDs on the /probe and write-answer parses; bound the streams walk to
64 levels (unbounded recursion = uncatchable StackOverflowException).
- Skip structural elements under <Components>/<Devices> instead of failing the probe.
- Never serve a non-finite float as Good; serve integer-valued decimals on
integral tags losslessly (a real fraction stays BadOutOfRange).
- Merge sibling browse containers that resolve to one folder name.
Unit 678 -> 714, integration 18 -> 20 (live fixture); every gap test failed first.
Native LSV2 TCP transport (default port 19000; no Java/Python/vendor SDK), ported from
the ignitionoee v0.20.3 reference driver:
- Lsv2ReadOnlyPolicy: payload-aware outbound allowlist (A_LG INSPECT/DNC/DATA, R_VR, R_PR,
R_RI 22-28/30-34/51, bounded R_MB, R_DP), applied in Lsv2Codec.Frame before any byte is
written; no public raw-command API.
- Lsv2Client: serialized transactions, one monotonic deadline per transaction, EOF/oversize/
unexpected/trickle close the client; Close never waits on the gate.
- Lsv2Data / HeidenhainBindingCodec: reference decoders, R_PR byte-capacity + element-index
rules, next-area cap, DATA Boolean 00/01/FF, strict non-truncating coercion.
- HeidenhainSession: per-batch wire cache, refusal suppression, provisional DNC denial retry,
alarm cursor (57 = end, overflow = BadOutOfRange), capability strings.
- HeidenhainDeviceRuntime: one loop per control, generation-guarded publication, all-node
BadNoCommunication on comms loss, bounded exponential reconnect.
- HeidenhainDriver: cached ReadAsync, push subscriptions deduped on (value,status), static
catalog discovery (no connect), host status, diagnostics, skipped-tag report. No IWritable.
- Probe (INSPECT login + version) and factory registered in DriverFactoryBootstrap (probe on
admin nodes too); DriverTypeNames.Heidenhain.
Tests: 159 unit tests against an in-process LSV2 simulator incl. the Java fixtures' captured
iTNC 530 replies as golden vectors; env-gated LiveIntegration category for real hardware.
QualityMixConformance (SqlServer + QuestDB): tag Q mixes Good/Uncertain/Bad
regions across eight 10 s intervals (two all-Uncertain - Mantis 0006166 0%/0% -
one through an Uncertain bound, plus Uncertain bounds from a sloped non-Good end
and from a hold into an outage); tag QP has data starting and ending inside
intervals. PercentGood/PercentBad are asserted against hand-derived Part 13
DurationGood/DurationBad, the oracle is checked against the same hand values,
and Count/Average/TimeAverage(2)/Total(2)/Start/End/Delta/Interpolative are
compared with the #796 oracle forward, reverse and truncated.
Teeth: with SdkAggregateOracle.Run answering PercentGood from the baseline
(Uncertain as Good), 8 tests fail per backend; all pass with the fixed oracle.
Records a new SDK divergence: with an Uncertain first sample the baseline
TimeAverage/Total integrate the pre-data span as a ramp from 0; ours covers
only the data (Part 13) - pinned by hand.
Docker/nat/docker-compose.yml (stack /opt/otopcua-opcuaclient-nat on 10.100.0.35,
project: lmxopcua label): opc-plc 2.14.10 advertising --ph=otopcua-opc-plc-nat-private.invalid
on internal 50000, published on 50010, so every advertised URL is unreachable on host AND
port. Deployed; NatPublishedEndpointBrowseTests 3/3 against opc.tcp://10.100.0.35:50010
(and the secure case positive once, with the fixture cert temporarily trusted).
CI: integration-fixtures.sh starts a throwaway otopcua-ci-opcplc-nat (50110->50000) and
exports OPCUA_NAT_ENDPOINT; v2-e2e.yml points it at the shared stack. infra/README.md,
scripts/ci/README.md and the fixture README document it.