Commit Graph

4799 Commits

Author SHA1 Message Date
Joseph Doherty 340d9d26b7 feat(mtconnect): CommsLossConfirmMs AdminUI field, refused-connection fast path reaches native conditions, dedicated live-gate Agent (#852)
- 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
2026-11-11 06:23:18 -05:00
Joseph Doherty d1b6cad299 Merge #843 #840 #841 load flakes, ScriptRunGuard slot-release race, DurationGood conformance, CI QuestDB pin
v2-ci / unit-tests (push) Failing after 1h12m19s
2026-11-11 05:10:00 -05:00
Joseph Doherty 997c78df8a test(host): deploy-seal waits in EquipmentNamespaceMaterializationTests report where the deploy stood on timeout (#843)
The one failure on record (48 s vs 3 s, load 140-280) was a bare 'Condition
not met within 00:00:45'. It did not reproduce here (0/10 alone and 0/6 full
Host integration runs under 28 CPU burners at load ~300), so no timing was
changed; the timeout now carries the deployment row (status, artifact size,
failure reason), every per-node state (status, started/applied, failure) vs
the expected node ids, and both nodes' cluster views - enough to tell a slow
apply from a node that never got the dispatch from a failed apply.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-11-11 05:09:36 -05:00
Joseph Doherty e89ac2df33 fix(scripting): the run guard frees a script's slot before its run completes, not in a continuation (#843)
RoslynVirtualTagEvaluatorTests' race test failed 1 of 3 full Host
integration runs under 28 CPU burners (load ~300) with 24 of 4,800
evaluations refused 'previous evaluation still running' - impossible for one
sequential loop per id unless a finished run still held its slot after the
caller resumed. It did: ScriptRunGuard released the slot from a ContinueWith
on the run task, and a task is observably complete before its continuations
run, so the next run of the key (the next trigger, or the evaluator's
immediate retry after a raced ObjectDisposedException) could be refused - a
Bad publish for a script that had finished. Same class #789 fixed for the
out-of-process runner.

The release (and the late allocation-breach quarantine, now ahead of it) runs
in the run's own finally, before the task completes; the continuation only
disposes the CTS and observes the fault. New
ScriptRunGuardTests.A_caller_that_awaited_its_run_is_never_refused_by_it
widens the release with a test hook (SlotReleasingForTest) - mutation-checked:
releasing from a continuation again refuses 49 of 50.

The race test now prints elapsed / evaluations / clears / pool state / failure
histogram on every assertion and on its hang detector. After: 3/3 full Host
integration runs pass under the same load. Core.Scripting 298, ScriptedAlarms
109, Calculation 84 green.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-11-11 05:09:36 -05:00
Joseph Doherty 4cd648b029 test(scriptworker): cold-burst heartbeat test kills at production's 3 s silence limit, self-diagnosing (#843)
Reproduced 10/10 under 28 CPU burners (load 100-250): every run killed for
157-309 ms of silence with every compile on its dedicated thread - the OS
scheduler, not the pool. A 1 s limit still failed 1/10 at load ~300
(1,012 ms). The limit is now production's (3 x the default 1 s heartbeat):
10/10 at load 360-430. The failure message now carries the burst length,
the limit and the kill line.

Also corrects the doc: its mutation check (compile on the pool again =>
killed for silence) no longer holds - re-checked on a 14-core Mac, the
mutated worker passed at both 150 ms and 1 s, because the host's silence
clock counts any frame and since #789 each compile writes its own
LoadStarted/LoadResult frames inline.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-11-11 04:36:29 -05:00
Joseph Doherty e3c71ec03e Merge #838 review fixes: per-condition deferral with liveness checks, lockout identity canonicalisation 2026-11-11 04:35:49 -05:00
Joseph Doherty 7482a88888 fix(opcua): deferred condition events never outlive their condition; per-condition bounded deferral; canonical lockout identity (#838, #844 review)
#838 review of 91e28e87:
1. Ghost events. The deferred report ran the SDK walk off Lock with no
   liveness check, so an event waiting behind a full channel when a deploy
   removed its condition still reached i=2253 subscribers after the NodeDeleted
   (a ghost Retain alarm for ScadaBridge) and folder items. Delivery now walks
   the SDK path itself (OnReportEvent, OnReportEventAsync, inverse notifiers,
   then the Parent as BaseInstanceState does) re-checking before EVERY hop that
   the condition is the live instance; OnConditionReportEvent checks the same,
   which also stops a non-live scripted condition being admitted through its
   old equipment folder (#808). A guard test pins that only NodeState and
   BaseInstanceState define the walk. New wire test removes the condition in
   the same Lock hold as a flood; the flag-off variant fails without the checks.
2. Back-pressure. Deferral is per condition (one condition's events stay
   ordered; others are not held up). Overflow policy: coalesce, newest wins --
   past 1024 waiting per condition or 65,536 total, the oldest not-yet-started
   event is discarded (a condition event carries full state). Warning on
   entering deferral (<=1/min), Information on draining, meters
   otopcua.opcua.condition_events.deferred / .discarded{reason}.
3. Verified by decompiling SDK 2.0.1: the notifier list is snapshotted under the
   node's own lock, MonitoredNode2.Dispose completes its writer (pending writes
   fail with ChannelClosedException, swallowed), and the consumer catches
   per-event exceptions -- no fix needed; documented.
4. ConditionRefresh does not flush deferrals; documented as a residual.

#844 review: DescribeCredential canonicalises the key id (trim, lowercase;
non-hex ids fold into one identity) and the username, since ConfigDbApiKeyStore
matches with SQL Server's case-insensitive, trailing-space-blind `=`.
2026-11-11 04:35:30 -05:00
Joseph Doherty fc3a39b1ca fix(mtconnect): name the comms-loss status constant so the status-code parity guard can see it (#846) 2026-11-11 04:13:55 -05:00
Joseph Doherty 214813bbc8 Merge #835/#836 review fixes (owner check on unsaved endpoints, credential-use audit) 2026-11-11 04:13:02 -05:00
Joseph Doherty 9072777422 docs(security): #835/#836 review — unsaved-endpoint rule, credential-use audit, accurate residuals, AuthType-gated path rule, directory resolution
Co-Authored-By: Claude <noreply@anthropic.com>
2026-11-11 04:12:40 -05:00
Joseph Doherty 271b96a644 fix(security): #835/#836 review — unsaved endpoints never borrow the owner's secrets; audit credential use; path rule follows AuthType
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>
2026-11-11 04:12:40 -05:00
Joseph Doherty 2fc9e02b9a Merge #844 #845 #838 credential lockout per identity, non-blocking condition events 2026-11-11 04:08:13 -05:00
Joseph Doherty 91e28e87be fix(opcua): condition events never block under the node-manager lock; folder-event Part 3 note (#838)
#838(a): each notifier's SDK MonitoredNode2 drains a bounded channel (4096,
FullMode=Wait) and the synchronous NodeState.ReportEvent blocks when it is
full. Condition events are reported under Lock, and the delivery task takes
Lock on a verdict-cache miss (the SDK's own GetNodeMetadata, flag off too, and
#826's IsNotifierEventVisibleTo). A burst of >4096 events under one Lock hold
therefore deadlocked the node manager -- FolderEventFloodWireTests stalled at
exactly 4097 events with enforcement on AND off (so it predates #826).

ReportConditionEvent now goes through ReportEventWithoutBlocking:
ReportEventAsync completes inline while channels have room (unchanged normal
path); on overflow the report continues asynchronously holding no thread or
lock, and later reports queue behind it so events keep report order. The
deferred work holds only frozen snapshots and drains once Lock is released;
client delivery stays bounded by each item's queue.

#838(b): documented in docs/security.md that Part 3 ReceiveEvents applies to
the event's EventType and SourceNode (the SDK check, kept first), not to the
notifier; our decision is layered on top and non-condition events on a folder
item fail closed under enforcement. The reflection item stays open.
2026-11-11 04:06:28 -05:00
Joseph Doherty 87416a230f Merge #835 #836 per-instance credential references, confined user-certificate path 2026-11-11 03:53:12 -05:00
Joseph Doherty 0810e808c9 docs(security): per-instance credential references (#835) and the user-certificate directory (#836)
Co-Authored-By: Claude <noreply@anthropic.com>
2026-11-11 03:52:48 -05:00
Joseph Doherty 63331b0416 fix(security): per-instance credential references (#835) and confined OpcUaClient user-certificate path (#836)
#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>
2026-11-11 03:52:48 -05:00
Joseph Doherty c3cf11c5e4 ci(questdb): CI keeps its throwaway QuestDB but reads the image pin from the shared fixture's compose file (#841)
Decision: CI stays hermetic (no dependency on the 10.100.0.35 box or its
state), but must run the same engine. integration-fixtures.sh now takes the
questdb/questdb:<tag> line from the #822 fixture compose file (10.0.1 today,
same as the docker-dev overlay) and fails 'up' if it is missing;
CI_QDB_IMAGE overrides for a trial. Documented in scripts/ci/README.md.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-11-11 03:50:12 -05:00
Joseph Doherty 7095e128e3 test(historian): shared ProcessedConformance asserts DurationGood/DurationBad on every SQL backend (#840)
#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>
2026-11-11 03:50:12 -05:00
Joseph Doherty 1696e34868 test(scripting): stuck-orphan quarantine test trusts the guard's young/stuck verdict, not the test's stopwatch (#843)
Reproduced 4/10 alone under 28 CPU burners (load ~100-250): outcomes were
timeout, refused, refused, quarantined... - the guard right, but the test
labelled a refusal 'young' only if its OWN stopwatch (started before the
first trigger) read < 600 ms, and the first trigger's dispatch + sibling +
cadence ate that. A ScriptRunRefusedException IS the young-orphan answer
(behind a stuck orphan the guard quarantines instead), so: any refusal
before the quarantine proves the rule; stuck factor 50 (3 s) so the second
trigger lands young with room; loop until three quarantined answers; the one
remaining wall-clock check runs the safe way (quarantine time on the test's
stopwatch >= threshold - the orphan is never older than it); runaway is a
held spin so it cannot end on its own; failures print the timeline.
After: 10/10 under the same load.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-11-11 03:50:12 -05:00
Joseph Doherty e39b436630 Merge #839 #846 Heidenhain alarms + MTConnect comms loss 2026-11-11 03:50:08 -05:00
Joseph Doherty 19ffb3c902 feat(mtconnect): refused Agent connections mark values BadNoCommunication after a short confirmation; clamp the raised sample count to the Agent's bufferSize (#846)
- 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
2026-11-11 03:48:05 -05:00
Joseph Doherty ffba75bb93 fix(opcua): credential lockout counts per credential identity, ignores transient denials (#844, #845)
#844: a success cleared the whole per-client count, so a client holding a valid
key could make four guesses at another key id, sign in with its own, and repeat
forever. Failures are now charged to the (client, credential identity) pair --
IOpcUaUserAuthenticator.DescribeCredential, key:<keyId> for API keys -- and to
the client as a whole. A success clears only its own identity's count; the
per-identity threshold locks that identity; a new per-client cap
(OpcUa:CredentialLockout:MaxFailedAttemptsPerClient, default 2 x
MaxFailedAttempts) that a success never resets bounds spraying across key ids
and locks every credential. Slots are bounded on both counts; a waiter whose
identity has room is not held behind one whose identity has none.

#845: (1) store timeouts, miss-budget refusals, store/authenticator exceptions,
an unset pepper and cancellation are reported Transient
(OpcUaUserAuthResult.Unavailable) and released uncounted, so a ConfigDb stall
can no longer lock every keyed client out; the client still sees the same
opaque denial. (2) a full 4096-entry table defers its next prune until the
earliest entry could age out instead of rescanning under the lock per failure.
(3) ApplicationUri deliberately NOT added to the key: on a None channel it is a
free string, so it would let an attacker reset the count per guess --
documented. (4) wire tests: distinct client certificates on a Sign endpoint are
locked out separately; the ApplicationConfigPath branch keeps the SDK lockout
off (falsified by removing the loaded-branch disable).
2026-11-11 03:47:10 -05:00
Joseph Doherty cbd2b715c0 Merge #847 AdminUI polish 2026-11-11 03:42:19 -05:00
Joseph Doherty c6c243cc06 fix(security): /raw credential banner keeps running findings when the ConfigDb scan fails (#847) 2026-11-11 03:42:01 -05:00
Joseph Doherty 038aa007ac fix(hosts): driver health is per (driver, node) — ReportingNodeId on DriverHealthChanged + telemetry proto (#847) 2026-11-11 03:41:59 -05:00
Joseph Doherty 3b1b32f0c1 fix(adminui): one save-error area per driver/device modal; refusal chips print the full message (#847) 2026-11-11 03:41:56 -05:00
Joseph Doherty 01a2013ea3 fix(adminui): CSV import grid shows the raw name/group of an invalid row (#847) 2026-11-11 03:41:54 -05:00
Joseph Doherty ddc1f29e3a feat(heidenhain): Part 9 condition class from alarm class/group, ALARM: selectors in the browse picker, unclaimed alarms counted + logged (#839)
- Core: optional AlarmEventArgs.ConditionClass (AlarmConditionClass Base/Process/Maintenance/System/Safety)
  carried by NativeAlarmProjector on AlarmConditionSnapshot and written to ConditionClassId/Name by
  OtOpcUaNodeManager (kept across class-less events, carried across a #641 rebuild).
- Heidenhain: HeidenhainAlarmClasses — HEIDENHAIN DNC-interface class/group names (corroborated by the
  captured iTNC 530 record); class 5 => Safety, group 1/2 => Process, 4/5 => System, else Base.
- DiscoverAsync offers AlarmConditions/ selector leaves (IsAlarm Boolean); browse-commit seeds the alarm object.
- Alarms no selector claims: otopcua.driver.alarms.unmatched counter, /hosts diagnostics, rate-limited Warning.

Refs #839
2026-11-11 03:41:14 -05:00
Joseph Doherty 481985e20b fix(security): preflight reads the latest sealed artifact; byte[] length predicate untranslatable on SQL Server (#815 live gate) 2026-11-11 03:29:41 -05:00
Joseph Doherty 2a87d96fac Merge fix/832-docker-dev-batch (#832 #833 #825 #823) 2026-11-11 02:44:01 -05:00
Joseph Doherty 2314a53842 fix(opcua): parallel UserName activations wait for a slot instead of being refused (#833)
A legitimate consumer opening several keyed sessions at once from one key
(e.g. a ScadaBridge site node behind one NAT IP on a None endpoint) must
never see BadUserAccessDenied for parallelism. BeginAsync now hands out at
most MaxFailedAttempts - recordedFailures verification slots per key;
further activations queue FIFO and are woken as slots free. A waiter is
refused only when the key locks out (never reaching the authenticator) or,
uncounted, after OpcUa:CredentialLockout:SlotWaitSeconds (default 10,
BadTimeout). Cancelled waiters are dequeued; pruning accounts for waiters.
The security property is unchanged: no more than MaxFailedAttempts
unverified guesses in progress-or-failed per window.
2026-11-11 02:43:38 -05:00
Joseph Doherty 9c0cdc05ef Merge fix/827-worker-deadline 2026-11-11 02:34:47 -05:00
Joseph Doherty 61b9adeb9c Merge branch 'master' into fix/832-docker-dev-batch 2026-11-11 02:32:42 -05:00
Joseph Doherty 988927c8ac fix(opcua): credential lockout review fixes (#833)
- Reserve an in-flight slot per client before authenticating (TryBegin /
  Complete / Abandon under one lock); refuse once recorded failures +
  in-flight attempts reach MaxFailedAttempts, so a parallel burst cannot
  pass the check before its first failure is recorded.
- An expired lockout resets the count (LockoutSeconds < FailureWindowSeconds
  relocked on every single failure after the first lockout).
- Validate OpcUa:CredentialLockout at host start: disabled, or both
  durations > 0.
- Log the first refusal of a lockout episode at Information, the rest at
  Debug.
- Document that on None endpoints the key is effectively IP-only (NAT
  neighbours share a UserName count) and a null peer shares one key.
2026-11-11 02:32:40 -05:00
Joseph Doherty 5962cc3d41 docs(scripting): T starts at the run's start but is wall clock from there; document the late-notice, ceiling and counter rules (#827 review F4)
The proto comment, VirtualTags.md and CLAUDE.md said the soft deadline
measures the script's own execution and that starvation never counts. Only
the time before the run STARTS is excluded; from the start it is wall-clock
time, as in process, so a GC pause or a starved / suspended worker during
the run still counts. A mid-run freeze test is not added: it races the
script's own return against the watchdog's notice; documented as not covered.
Also documents F2 (late notice cancels and counts), F3 (progress ceiling,
killed_unresponsive) and F5 (answer = stalled | retrigger).
2026-11-11 02:26:28 -05:00
Joseph Doherty 5bfe8386e6 Merge fix/826-folder-event-acl 2026-11-11 02:25:44 -05:00
Joseph Doherty 489664d105 fix(acl): folder event verdict invalidation reaches orphaned items, decides on the live instance, skips ACL-off; alarm-bearing reference map (#826 review)
1. Orphaned subscriptions (session closed without deleting subs) have Session == null, so the public
   MonitoredNode2.InvalidatePermissionCacheForSession never reached their items: a cached allow survived a
   deploy, the moved alarm's events were queued and delivered on TransferSubscriptions. Each notifier's
   cache is now cleared whole through the SDK's private MonitoredNode2.InvalidatePermissionCaches
   (reflection, guard-tested; fallback = per-session drop + Remove/Add of session-less items).
2. The verdict is decided on the condition LIVE under the event's NodeId, not the event's own instance:
   the SDK key has no instance identity, so a replaced instance's late event cached a deny its successor
   inherited until the next invalidation.
3. Invalidation is skipped while enforcement is off (forced across an on/off authorizer transition) and is
   one O(cache) clear per node instead of a whole-cache scan per item.
4. TagReferencingEquipment carries alarm-bearing raw tags only (TagConfigIntent.Alarm); the snapshot adopts
   an OrdinalIgnoreCase set instead of copying it again.

Tests (each fails with its fix reverted): FolderEventVerdictInvalidationTests (orphan + deploy + transfer;
replaced instance's late event; ACL-off skip; SDK private-method guard), DeploymentArtifactScopePathTests
(alarm-bearing projection).
2026-11-11 02:25:24 -05:00
Joseph Doherty f50209082e fix(scripting): count re-triggers answered 'worker unresponsive' (#827 review F5)
otopcua.script.ipc.timeouts gains the tag answer = stalled | retrigger: a
later trigger refused while the key's stalled run is still unanswered was
counted nowhere (not a script refusal, so not ScriptRunGuard's counter).
KeyState carries its Kind for the tag. Test: the stalled-worker test listens
for the instrument and sees one stalled and one retrigger (before: only
stalled).
2026-11-11 02:25:17 -05:00
Joseph Doherty 3f5fdd1d90 fix(scripting): host progress ceiling kills a worker that heartbeats but never answers (#827 review F3)
With the worker soft clock the host's per-run timer is only the transport
deadline and the kill G is armed by the worker's soft notice, so a worker
whose watchdog never sends one (the design treats the worker as untrusted)
held the run, the key's slot and the tag's BadResourceUnavailable forever;
a deferred run had no host clock at all.

WorkerConnection now tracks evaluation progress (any Results frame, or the
submit that made the worker busy again). The heartbeat monitor kills the
worker when evaluations are outstanding and nothing progressed for
3 x the largest outstanding T + G plus the silence limit:
WorkerExitReason.KilledUnresponsive (metric reason killed_unresponsive), no
culprit, attributed like a heartbeat kill (suspects, no strike on a shard).
A legitimate run has answered or been killed by T + G.

Test seams: ProgressCeilingOverride, DropSoftNoticesForTest. Tests: a worker
whose notices are dropped (G = 60 s, so its own fail-fast cannot end it) is
killed KilledUnresponsive after the 1.5 s ceiling and the run is released
(with the check disabled the test times out); positive control: 20 queued
150 ms runs behind one thread under a 1 s ceiling are all answered.
2026-11-11 02:24:01 -05:00
Joseph Doherty c5dffbd8b8 fix(scripting): soft notice resumes the caller on the dispatcher; a late notice still cancels and counts (#827 review F1, F2)
F1: PendingEval.SoftExpired runs on the pipe reader and completed the
caller's outcome inline (production outcomes have no async continuations, by
design, because results resume on the ResultDispatcher). The caller's code
(the runner's Cancel write, the seam's publish) therefore ran on the reader,
which could hold up heartbeats (a false silence kill) and, with blocking I/O,
write to the pipe from the reader. The soft outcome is now posted to the
dispatcher like a result. Test: a 1.5 s continuation on a soft timeout with a
300 ms silence limit resumes on ScriptWorker[t].Results and the worker lives
(before: it resumed on the Reader).

F2: after a transport timeout ("worker unresponsive"), a late soft notice
armed G but nobody sent Cancel, so a polling script could not end itself and
the whole worker was killed at notice + G with a kill strike. PendingEval now
exposes LateSoftExpiry (asynchronous continuations, cancelled when the run
answers); the runner sends the Cancel and counts the timeout
(NoteTimeout) from it. A genuinely slow script whose notice is delayed past
T + G is thus still a timeout. Test: stall past T + G, thaw, a polling
script overruns T: the worker survives G and the key is quarantined for
timeouts, not a hard deadline (before: the worker was killed).
2026-11-11 02:21:45 -05:00
Joseph Doherty e9064dbdf4 Merge fix/815-credential-ref-scoping
# Conflicts:
#	.claude/rules/drivers.md
#	docs/drivers/Heidenhain.md
2026-11-11 02:08:24 -05:00
Joseph Doherty 4563940792 fix(security): warn-only compatibility for deployed drivers + upgrade preflight (#815)
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>
2026-11-11 02:07:33 -05:00
Joseph Doherty 30c6c3a0bf test(scriptworker): tight-heartbeat tests warm the worker before arming the 3-interval silence kill (#827 audit)
A_burst_of_cold_compiles_does_not_starve_the_heartbeat (50 ms heartbeat) and
A_caller_that_blocks_after_its_result_does_not_starve_the_reader (100 ms)
launched their worker under the tight silence kill, so its cold start (launch,
first compile, Roslyn JIT) had to stay within 150/300 ms of silence. Under 28
busy processes on 14 cores that failed 2 in 10 runs in the WARM-UP, killed
for ~165 ms of silence, and replacement workers were killed the same way.

New test seam: WorkerLaunchSpec.HeartbeatSilenceLimit (runner internal ctor
heartbeatSilenceLimit) and WorkerConnection.SilenceLimit. The tests launch
with a 30 s limit, warm, then tighten to three intervals for the phase they
are about; the assertions on that phase are unchanged.
2026-11-11 01:59:11 -05:00
Joseph Doherty 0856261ef8 docs(mtconnect): record the #823 end-to-end gate (adapter stop, Agent restart/stop/pause, genuine overrun) 2026-11-11 01:57:21 -05:00
Joseph Doherty d3285af6b3 Merge feat/830-heidenhain-followups 2026-11-11 01:55:10 -05:00
Joseph Doherty 97991e0476 test(heidenhain): wait for the whole outage-invalidation pass before asserting on it (pre-existing race)
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.
2026-11-11 01:54:53 -05:00
Joseph Doherty 9d91c41847 docs: MIN_EXECUTED raise is 448 after the #816 review tests; fixture-label note reflects reality 2026-11-11 01:54:01 -05:00
Joseph Doherty 598c668108 Merge fix/816-opcuaclient-cert-host 2026-11-11 01:53:35 -05:00
Joseph Doherty 8d64db6764 Merge feat/820-duration-good-bad 2026-11-11 01:53:34 -05:00
Joseph Doherty 9aff90f884 fix(opcuaclient): never pass checkDomain for a None endpoint; document both SDK check sites (#816 review)
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.
2026-11-11 01:53:01 -05:00