The SDK keeps sending publish requests on a session that holds no subscription,
and the server answers each with an anonymous (id 0) BadNoSubscription. With
nothing tracked there is nothing to lose, but every answer was counted on
publish_error, so the counter climbed on any idle driver. HandlePublishError now
returns before counting for an anonymous BadNoSubscription while neither the data
nor the alarm subscription set tracks anything. Any other PublishError, and an
anonymous one while something is tracked, is counted and handled as before.
Not addressed from #764: the Republish/state probe for a delete slower than the
5 s grace, and the #728 wire test's fixed delays.
Tests: OpcUaClientIdlePublishErrorTests (idle session: not counted, with a
BadTooManyPublishRequests positive control; tracked subscription: still counted).
The idle test fails without the fix. Driver.OpcUaClient.Tests 295/0.
Docs: drivers/OpcUaClient.md.
Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
The #733 miss budget (MissBurst / MissRefillPerSecond) was process-wide, because
the impersonation seam did not carry the client's address, so one noisy client
could drain it for everybody while the known-id set was unavailable.
- IOpcUaUserAuthenticator gains AuthenticateUserNameAsync(username, password,
IPEndPoint? remoteEndpoint, ct) (default: ignore it and delegate); the server
calls it. IApiKeyStore gains the matching FindAsync(..., IPAddress?, ...).
- The SDK (1.5.378) exposes no client address publicly. SecureChannelRemoteEndpoint
reads it from the TCP listener's private channel map by reflection (through the
protected TransportListeners, exposed by OtOpcUaSdkServer), keyed by the
session's secure channel id. Fail-soft: null => process-wide accounting only.
- BoundedApiKeyStore charges a miss to the client's own bucket (keyed by IP
address) and then to the process-wide one, which stays as the backstop. A
refusal by either takes from neither; a hit refunds both. New options
Security:ApiKeys:MissBurstPerEndpoint (10) / MissRefillPerSecondPerEndpoint (2),
0 disables; telemetry outcome endpoint_miss_budget. At most 4096 addresses are
tracked; idle buckets are pruned and an address beyond the cap is charged
process-wide only.
Tests: BoundedApiKeyStorePerEndpointTests (noisy client vs quiet one, process-wide
backstop, refund + refill, off switch, authenticator forwards the address) and
over the wire ApiKeyMissBudgetPerEndpointTests (activation hands the authenticator
the loopback endpoint; real authenticator + bounded store refuse a client's 3rd
and 4th unknown key without a store read). Each new behaviour test fails with its
fix mutated out. Host.Tests 224/0, OpcUaServer.Tests 1019/1 (the one failure is the
wall-clock NodeManagerLockTimeBenchmarkTests under suite load; passes alone).
Docs: security.md.
Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
Residuals of #760:
1. An ack routed to a disabled / not-yet-spawned driver, or to a stubbed child
(driver does not run on this node, or its factory rejected the config), was
dropped and left the condition Acknowledged. DriverHostActor now reverts it
through the same NativeAlarmProjector.TryRevertAcknowledge path as a refused
ack.
2. A native ack arriving while the host is Stale was dropped before the projector
recorded it, so it could never be reverted. Stale now marks the whole
NativeAlarmAckPreflightView unavailable (SetHostUnavailable), so the node
manager refuses every native ack synchronously; BecomeSteady clears it.
3. (keyed sessions without a Galaxy identity) is not addressed.
Tests: DriverHostActorNativeAlarmTests' client-ack test and the FOCAS respawn
theory now start the host's own driver (AckingAlarmDriver; a factory building a
real FocasDriver per spawn over per-instance fake CNCs, with a real config-change
respawn) - both fail against the fix in their old shape. New: ack to a disabled
driver reverts, ack to a stubbed driver reverts, a Stale host refuses through the
pre-check view - all three fail with the fix mutated out. Runtime.Tests 943/0/3.
Docs: AlarmTracking.md, .claude/rules/alarms.md.
Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
R06:M-5 made Sparkplug *Array leaves unbindable because the ingestor refused
them. The ingestor now decodes the packed bytes_value of all 13 array types
(new SparkplugArrayCodec: LE fixed widths, int64-ms DateTime, uint32-count +
MSB-first BooleanArray, NUL-terminated UTF-8 StringArray; golden vectors are
the spec's examples, reproduced byte-for-byte by SparkplugNet 1.3.10) and
publishes a typed 1-D array through the scalar coercion, all-or-nothing.
Shape uses the fleet's one array model: the tag's platform isArray/arrayLength
keys (ValueRank=1, ArrayDimensions=[arrayLength] or [0] = variable length).
The driver reads them with TagConfigIntent's exact semantics and never rejects
on them, so composer, driver, authoring model and deploy inspector agree
(MqttArrayShapeParityTests). Shape mismatch (array metric on a scalar tag, or
the reverse) is BadTypeMismatch - a String tag no longer gets the packed
buffer as base64; a malformed body or a length differing from a declared
arrayLength is BadDecodingError (MTConnect #627 verdict). Template/DataSet
members of an array type take the same path. Arrays stay read-only (encoder
refuses them; MQTT is not in DriverArrayWriteSupport).
Browser: array leaves are bindable again and state isArray in AddressFields;
the commit mapper writes "isArray": true (no arrayLength). Discovery reports
IsArray/ArrayDim. Docs: Mqtt.md "Array metrics", design table, drivers rule.
Tests: codec (golden + malformed), ingest through the real codec + Dispatch
(the project's MiniBroker never forwards a PUBLISH, so there is no in-process
broker harness), browse, discovery, commit mapper, parity corpus. Mutating the
fix back out fails 18 MQTT + 9 AdminUI tests.
Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
AbLegacyDriver stamped LastSuccessfulRead (init, read, write), the read's sample
timestamps and host-state change times from raw DateTime.UtcNow; only the backoff
path read the injectable Clock. All of them now read Clock(), so
NativePcccBridgeStatusHealthTests drives a manual clock (init at T0, read at T1)
and asserts LastSuccessfulRead == T1 exactly instead of spin-waiting for the wall
clock to pass the init stamp (#767). Without the driver change the 6 tests fail
(init stamp is wall-clock, not T0).
Directory.Build.props sets TreatWarningsAsErrors for every project since #766, so
the 75 per-csproj copies (64 src + 11 tests) are removed and the props comment
says it is now the only place. tools/mem-probe keeps its explicit opt-out.
Release build: 0 warnings, 0 errors; a planted CS0168 still fails the build in a
src project and in a test project that used to carry its own copy.
Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
Awaited deadline oracles get the runner token; sync-over-async tests made async;
nullable child models asserted via ShouldNotBeNull() return values; obsolete SDK
ctors replaced with the telemetry overloads; dead locals/fixture and the redundant
DI PackageReference removed. No suppressions.
Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
Bounded, docs-only part of #717. The standard non-transparent metadata itself is
NOT implemented here: the owner-selected contract is Warm with an explicit
degraded standby (and None standalone), today's Secondary serves Good data at
ServiceLevel 240, and the design/spec/plan for that standby behaviour is still
pending on the issue - advertising Warm now would misdescribe the server.
- docs/Redundancy.md: the "deferred pending an SDK object-type upgrade" claim
was wrong; the pinned SDK 1.5.378.106 can build the graph
(CreateMainNodeManagerFactory + AddBehaviourToPredefinedNode). Records what is
and is not advertised (i=3709 SDK default, no i=11314) and the blocker.
- .claude/rules/cluster-topology.md: said each node exposes RedundancySupport
and ServerUriArray; it does not.
- docs/OpcUaServer.md: same false claim about the SDK exposing them.
Interop gates A4/A5b stay unmet; no product code changed.
Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
The native-ack path is fire-and-forget after the Part 9 Acknowledge returns, so
a Galaxy ack refused for lack of a credential (#749), or rejected by MXAccess,
left the client with Good and the condition showing Acknowledged while Galaxy
was never acked. Both options from the issue, layered:
1. Synchronous refusal for what the driver can decide without I/O. New optional
IAlarmAcknowledgePreflight (Core.Abstractions); GalaxyDriver refuses an
anonymous session when the driver has no AlarmAck fallback account.
DriverHostActor publishes a condition NodeId -> owning-driver snapshot into
NativeAlarmAckPreflightView (lock-free, volatile); the node manager's new
NativeAlarmAckPreflight seam, consulted after the authz and Primary gates,
returns BadUserAccessDenied with the reason - nothing routed, applied or
reported. A keyed session is not pre-checked (its identity lives in the
Secrets store, which must not be read under the SDK lock).
2. Revert for everything else. RouteAlarmAck carries the condition NodeId;
DriverInstanceActor replies AlarmAckFailed on a refusal, a backend failure,
a driver without an alarm source, or an ack dropped while connecting /
reconnecting. DriverHostActor re-projects the condition unacknowledged via
NativeAlarmProjector.TryRevertAcknowledge, but only while the local ack is
still pending - any driver transition projected since then wins. A disabled
or unspawned driver is not reverted (unchanged).
GatewayGalaxyAlarmAcknowledger now throws DriverOperationRefusedException on a
non-zero native hresult (wrong password, no ack permission) instead of only
warning, so that case reverts too; as a refusal it neither retries nor trips
the breaker.
Tests (each fails with its fix mutated out): projector revert/supersede,
DriverHostActor refused + failed ack -> unacked AlarmStateUpdate (successful
ack control), preflight view snapshot, node-manager preflight unit test,
over-the-wire ConditionMethodEventTests (BadUserAccessDenied, no route, no
event; positive control), Galaxy preflight + hresult refusal.
Docs: Galaxy.md, AlarmTracking.md, ScriptedAlarms.md, .claude/rules/alarms.md.
Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
HandlePublishError treated any anonymous BadNoSubscription (subscription id 0)
as loss of a tracked subscription whenever anything was tracked. A publish the
SDK queued while the session briefly held no subscription (the driver had just
deleted its last one) is answered BadNoSubscription and can be handled after the
replacement is registered, so a routine unsubscribe->resubscribe declared the new
subscription lost: Degraded health, a BadNoCommunication quality-only annotation
on every alarm condition and ConnectionLost (host re-initialise). That annotation
is the extra Unspecified event the #728 ConditionRefresh wire test saw.
- A BadNoSubscription that names a subscription id now counts only if the id is
tracked (same rule as BadSubscriptionIdInvalid).
- An anonymous BadNoSubscription within StaleNoSubscriptionGrace (5 s) of a
delete that left the driver tracking nothing is ignored (Debug log). The window
is stamped on removal and restarted when the delete completes; failed-subscribe
removes stamp it too; it resets with the session. A delete that leaves another
subscription tracked opens no window. A genuine loss inside the window is still
caught by the PublishingStopped heuristic; only the immediate ConnectionLost is
forgone.
- OpcUaClientStaleNoSubscriptionTests: stale error after alarm and data
resubscribe (fail without the fix), id-attributed stale error, plus genuine-loss
controls (after the grace, no recent delete, non-last delete, session release),
each mutation-checked.
- The ConditionRefresh wire test now records Kind, AlarmType, message, severity,
quality, restatement and timestamp of every post-resubscribe event on failure.
Wire test locally: 1/10 passing before (all 9 failures the 0x80310000
ConditionType quality-only annotation), 12/12 after.
Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
QuestDB speaks plain PG-wire; Npgsql's default GSS Encryption Mode=Prefer made
Linux central boots log 'Cannot load library libgssapi_krb5.so.2'. An explicit
'GSS Encryption Mode' in the connection string is kept. Not re-verified on a
Linux host here; the Postgres Sql driver is left alone (Kerberos may be wanted).
Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
IOpcUaEndpointState (review-remediation lane C3) recorded whether the SDK started, but only the
health check read it: a fused node whose endpoint failed (port 4840 taken, ...) still advertised
250/240 and stayed driver Primary, so the Primary-gated data plane ran on a node no client could
reach while the reachable peer refused writes as Secondary.
- Move IOpcUaEndpointState/OpcUaEndpointState/OpcUaEndpointStatus from Host to Commons.OpcUa so
the Runtime can consume it.
- ServiceLevelCalculator: new NodeHealthInputs.EndpointListening (default true). false -> 0,
ahead of every other input including the +10 Primary bonus.
- OpcUaPublishActor: takes the endpoint state, feeds EndpointListening (Failed only; NotStarted /
Stopped are boot/shutdown windows) on the DB-backed and DB-less paths, and the legacy role-only
seam returns 0 for a failed endpoint too.
- New OpcUaEndpointStepDownActor (one per driver node): a Primary (ClusterActiveNode.SelfIsActive,
the election's own primitive) with a Failed endpoint gracefully Leaves its mesh - the manual
failover mechanism - so the peer becomes the oldest Up driver member, hosts the singletons and is
Primary. The election itself is not bent. Guarded on a peer having recently reported its own
endpoint listening (EndpointReport on the new opcua-endpoint-state DPS topic), so a pair with
both endpoints failed does not bounce the role and restart nodes forever, and a lone node never
shuts itself down.
- Runtime now references ZB.MOM.WW.Health.Akka (ClusterActiveNode).
- Docs: Redundancy.md tier table + new "Endpoint start failure: Primary step-down" section;
cluster-topology rule; HealthEndpoints / OpcUaEndpointHealthCheck comments (F-C1 landed).
Tests: ServiceLevelCalculatorTests (+5), OpcUaPublishActorEndpointStateTests (7: DB-less,
DB-backed, legacy seam, NotStarted/Stopped do not demote, a later failure demotes on the next
tick), OpcUaEndpointStepDownActorTests (3, real two-node mesh: holds while the peer is not
listening then leaves and the peer becomes Primary; failed Secondary and healthy Primary never
leave). Each was mutation-checked: removing the calculator/legacy guard, the Leave, the
peer-listening guard or the Primary guard fails the corresponding tests.
Reinitialize_clears_lastPublished_and_lastWritten_caches now registers
ShutdownAsync through the shared FixtureCleanup helper before initialize, so an
initialize, seeding/polling-observation or assertion failure still stops the
probe loop and poll engine and disposes the transport; the primary failure is
preserved and a secondary cleanup failure is reported. The in-body shutdown
still precedes the cache-clearing assertions; the trailing cleanup is safe
because shutdown is idempotent. Default probe settings, the real subscription
path and the bounded deadline are unchanged.
New Cache_reset_fixture_failure_after_subscribe_still_shuts_the_driver_down
fails the body after the subscription is live and asserts the sentinel is kept,
the transport is disposed exactly once, caches are cleared and no further poll
or probe sends land (fails if the cleanup is removed). No production change.
The probe and re-probe worker delegates closed over the CTS and read cts.Token
when they ran. Task.Run's scheduling token only suppresses a still-queued task;
a worker the pool had committed to but not yet invoked could outlive the bounded
join in StopBackgroundLoopsAsync, after which the CTS was disposed and the
delayed cts.Token read threw ObjectDisposedException (faulting the loop task).
Snapshot the token value before Task.Run and close over it; a token value stays
valid after its source is disposed and still reports cancellation, so a late
worker exits cleanly on its own generation's cancelled token. Document the
bounded-join / transport-lifetime limits on StopBackgroundLoopsAsync.
Regression tests park the worker deterministically in TPL's synchronous
TaskStarted event (after the start check, before the delegate runs) via an
EventListener correlated to the one TaskScheduled on the test's armed flow:
shutdown (probe + reprobe) and replacement-generation identity. All three fail
with ObjectDisposedException without the fix.
mxaccessgw 488649c+ acknowledges by AuthenticateUser as the operator and
writing <alarm>.AckMsg, so AcknowledgeAlarmRequest now needs a real Galaxy
operator_user + operator_domain + operator_password. The OPC UA data plane is
API-key/anonymous and carries no Galaxy password, so the driver resolves the
account per ack:
1. the session's API key's Galaxy ack identity, set on AdminUI /apikeys and
held ONLY in the encrypted Secrets store
(otopcua/apikeys/<keyId>/galaxy-ack-{user,password}); a key with a user but
no password secret is refused, never silently downgraded;
2. else the driver instance's AlarmAck fallback account (DriverConfig
FallbackUser/FallbackDomain/FallbackPasswordSecretRef; secret:/${secret:}/
env:/file:/dev: only — a literal is refused);
3. else DriverOperationRefusedException: nothing is sent, never an empty
operator_user. Refusals are excluded from the resilience retry/breaker.
- Pins ZB.MOM.WW.MxGateway.Client/Contracts 0.2.1 -> 0.3.0 (vendored into
nuget-packages/ from the Gitea feed, per the local-mxgw source mapping).
- The session ApiKeyId (not a secret) rides NativeAlarmAck ->
RouteNativeAlarmAck -> RouteAlarmAck -> AlarmAcknowledgeRequest; the password
is resolved in the driver and never travels through actor messages.
- The OPC UA principal + key id are appended to the AckMsg comment
("[OPC UA: <principal>, key <id>]") and logged, so a shared fallback
account does not lose who acknowledged.
- Password never logged: GalaxyAckCredential.ToString omits it, the
acknowledger scrubs it from every gateway message it echoes and withholds
any exception that echoes it; audit rows carry only the key id.
- AdminUI: /apikeys "Ack identity" editor (set/remove; delete-key clears it),
Galaxy driver form gains the fallback section.
- Synchronous OPC UA status for a refusal is follow-up #760 (the native ack
path is fire-and-forget after the Part 9 method returns).
Tests: request mapping (password populated), password absent from logs and
exception text (mutation-checked), key-mapped / fallback / refused paths at
resolver and driver level, breaker non-trip, key-id propagation through node
manager and DriverHostActor, AdminUI service + form round-trip.
Closes#749
Staleness is not a liveness proof: DriverInstanceActor publishes on a 30 s
tick and dedups on a health fingerprint, so a running Faulted, idle or
tagless driver deleted in /raw (undeployed) went stale and was evicted.
DeployedDriverSet reads, per node, the latest and latest-Applied
NodeDeploymentState deployments plus the latest Sealed one, parses each
artifact's DriverInstances once (cached by deployment id, pruned to the live
set, only the blob column of an uncached row is read), and /hosts unions it
with the authored set. The staleness clause stays as a second guard. An
unreadable artifact or a failed read means 'unknown' and skips eviction.
The Hosts.razor comment claiming '~12 health publishes' is corrected.
N-new-2: the CSV download's Content-Disposition carries an RFC 5987
filename* via ContentDispositionHeaderValue.SetHttpFileName.
Refs #745
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
I1: /hosts eviction keyed off the AUTHORED DriverInstances, so a driver
deleted in /raw but not yet deployed - still running, still publishing -
vanished every tick. The stores' sweep is now EvictUnconfiguredStale(set,
olderThanUtc): an entry goes only when unconfigured AND its PublishedUtc is
older than 60 s. Tests cover unconfigured+fresh kept (positive control),
unconfigured+stale evicted, configured+stale kept, and the deploy hand-off.
M1: Test Connect falls back to the driver's documented ProbeTimeoutSeconds
default (Galaxy 30, OpcUaClient 15, ...) when the key is absent.
M2: the budget never undercuts the config's root timeoutMs (+1 s).
M5: RawBrowseModal compares the open generation captured before the await,
so a close-then-reopen mid-open closes the stale session.
M6: /alarms-historian keeps a 30 s re-poll on 'not registered' (a driver
node may still be starting) instead of stopping for good.
M7: /scripts source fetch failure shows in the dialog, not the error boundary.
M8: the export modal maps only its preview rows; the endpoint writes via
Results.Text (no second byte[] copy).
M9: /hosts config queries are ordered, so SequenceEqual change detection holds.
N1 tooltip, N2 CRLF-safe regex, N3 throttled Warning for subscriber faults,
N5 equipment-import help text now describes RFC 4180 quoting.
H3: MonacoEditor documents that themes are page-global.
Refs #745
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
RequestRebirthAsync now returns only once the requested births have arrived
and gone quiet, but the modal still told the operator to re-expand the tree in
a few seconds. ConfirmRebirthAsync re-keys the tree against the same session
(as the Refresh button does) when the session and modal are still current,
drops the now-stale armed scope, and keeps the outcome line.
Refs #745
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
Since R06:M-5 the Sparkplug browser states no address for a metric the
ingestor will not read and labels its descriptor 'not bindable', but the commit
refusal still said the attribute lookup returned nothing. RawBrowseModal keeps
the attribute's descriptor on the selection, and DescribeUncommittableLeaf
reports a 'cannot be bound in this version' message echoing it. A lookup that
really returned nothing keeps the old text. A test pins the marker to the
browser's own string.
Refs #745
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
/fleet/diagnostics was reachable only by URL: each driver-role row on /fleet now
links to it (FleetDiagnosticsLinks.For escapes the host:port node id). The
diagnostics table showed the string instance id because the wire carries no
display name; the page now joins the authored DriverInstances.Name from the
ConfigDb (enrichment only - a failed lookup falls back to the id) and keeps the
id beneath the name.
Refs #745
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
The dev-only demo page shipped routable in the production assembly. Its route
moves from @page to a code-behind [Route] under #if DEBUG, so a Release build
keeps the component type (nothing referencing it breaks) but has no /dev/*
route. The page-authorization guard classifies it under #if DEBUG, and
DevRouteGatingTests asserts each configuration's half (Debug: exactly the demo
route; Release: none) with a positive control. Both configurations pass.
Refs #745
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
ImportEquipmentModal split each line on commas, so a quoted Manufacturer with
a comma silently shifted every later column. EquipmentCsvParser reads through
the shared Commons CsvParser (the raw-tag importer's authority): quoted
fields, doubled quotes, CRLF and blank lines parse; malformed quoting is an
error, not a guess. Header/shape messages are unchanged.
Refs #745
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
v3 retired the equipment-Tag binding; IUnsTreeService.LoadTagsForEquipmentAsync
always returned empty, had no production caller, and EquipmentTagRow plus the
always-zero tagCounts / EquipmentRow.TagCount plumbing existed only to feed it.
All removed; the equipment badge count (virtual tags) is unchanged.
Refs #745
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
The equipment page's Remove reference / Delete virtual tag / Delete alarm row
buttons and the ScriptEdit Delete ran on the first click. They now go through
RawConfirmDialog, the same confirm /raw uses. The ScriptEdit change is limited
to the delete button and its dialog.
Refs #745
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
I-1 - a non-C# paste produced thousands of Roslyn markers. The Problems-list report sent
them all as one JS->.NET message, which could exceed the 1 MB hub limit and kill the
circuit. The JS now sends at most MaxReportedMarkers (200) markers, errors first, each
message cut to 300 chars, plus the total. ScriptProblemReport.FromEditor clamps again
on arrival, and the Problems list shows "showing N of Total" / "+N more". The diagnostics
endpoint also bounds its response: Distinct, errors first, MaxDiagnosticMarkers (1000),
messages cut to 1000 chars. An error is always kept, so editor-accepts == publish-accepts.
M-1 - drop the per-instance compilation lineage; analyses derive from BaseCompilation.
M-2 - the VirtualTag modal clears its problems (and editor ref) on close and on a script
reload.
M-5 - an over-limit text is announced at blur (OnSyncRefused), not only at Save.
Refs #745
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
Every picker and RawBrowseModal stored the OpenAsync token after the await; if
the host was disposed (or the modal closed) meanwhile, its teardown had already
run with no token, so the plant-side session lived until the reaper's idle TTL.
BrowseOpenGuard.CloseIfOrphaned closes it at once; each host sets _disposed
first thing in DisposeAsync (RawBrowseModal also treats !Visible as gone).
Refs #745
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
A bare catch {} turned every failure - including an admin-only node with no
historian actor - into 'Loading...' forever, and a tick queued after a sync
timer dispose still issued an Ask. HistorianSinkStatusReader classifies a poll
as Available / NotOnThisRole (permanent: shown once, polling stops) /
Unavailable (shown with the last good status and its read time). The page
follows Fleet.razor: IAsyncDisposable, _disposed, overlap guard, page CTS.
Refs #745
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
DriverTestConnectButton always sent a fixed 10 s, and the Galaxy/OpcUaClient
probes run under min(caller, configured), so ProbeTimeoutSeconds - documented
on every driver as the Test Connect timeout - could shorten a probe but never
lengthen it (Galaxy's 30 s default was silently 10 s). TestConnectBudget reads
the root ProbeTimeoutSeconds from the merged config at click time, clamped to
the actor's [1, 60] s, falling back to TimeoutSeconds. DeviceModal (the only
host) needs no change. The Galaxy/OpcUaClient form hints are reworded.
The 30 s result-clear timer moves from System.Timers.Timer + an async void
Elapsed lambda to System.Threading.Timer, drained in DisposeAsync, with a
generation so a superseded fire cannot clear a newer result.
Refs #745
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
DriverConfigModal, DeviceModal and RawTagModal built a new parameter dictionary
and new EventCallbacks every render. ParameterDictionaryCache reuses the
dictionary while the values it carries are unchanged and rebuilds it with the
current value when one changes (a build-once cache would strand edits); the
callbacks are created once. Blazor still re-sets an EventCallback-bearing
child's parameters per parent render, so the forms' _lastParsed guards stay.
Refs #745
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
The import review grid rendered every parsed row and re-scanned all rows three
times per render for its counts; RawCsvReviewView recomputes rows + counts once
per change and the grid renders through Virtualize. The export modal embedded the
whole CSV twice (textarea + base64 data: URI); it now shows a 50-line preview
and links to GET /api/raw/tags.csv (ConfigEditor, like /raw), which runs the same
RawTagCsvExport builder and sends it as an attachment.
Refs #745
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
R04:P1 - the editor no longer ships its full text JS->.NET on every keystroke. It reports
at blur, and ScriptEdit / the VirtualTag modal read the authoritative value with
MonacoEditor.GetValueAsync at Save (validation runs after the pull). The Blazor hub's
MaximumReceiveMessageSize is raised from 32 KB to 1 MB. The JS refuses a value that
would not fit instead of sending an oversize frame, and Save surfaces that refusal
rather than persisting stale source.
R04:P9 - diagnostics carry a generation counter and an AbortController. Every edit aborts
the in-flight request, and only the response for the current text is applied.
R04:S2 - the always-empty inlay-hints provider, endpoint, service method and contracts
are removed.
R04:S3 - MarkersChanged is typed (DiagnosticMarker) and feeds a new ScriptProblemsList.
Clicking a problem calls RevealLineAsync. Markers cross to .NET only when a list
subscribes.
Refs #745
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
The deploy API's 202 pointed at a route that did not exist. The GET reads the
deployment (projected, no artifact blob) and its per-node apply states, and
carries the POST's own gate: API key in X-Api-Key, AllowAnonymous against the
cookie fallback, 503 while Security:DeployApiKey is unset.
Refs #745
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
The FC15, FC16, BitInRegister[] RMW FC16 and FC23 builders allocated one byte
more than they filled (6 + 1 + n / 10 + 1 + n, data copied at offset 6 / 10), so
every multi-write request ended in a stray zero byte beyond the length its
byte-count field declares. A strict device or gateway that checks the frame
length against the byte count refuses it. Each PDU is now exactly 6 + n
(FC15/FC16) or 10 + n (FC23).
Tests: ModbusWritePduFramingTests asserts the exact length and byte-count field
for FC15 (1/8/9 coils), FC16 (array, Float64 array, scalar multi-register), the
BitInRegister RMW FC16 and FC23 (all red before the fix); the parity suite's
conformant device now refuses a frame-length mismatch on FC15/FC16/FC23.
Live: Driver.Modbus.IntegrationTests against 10.100.0.35:5020 (standard
profile) passes, including the FC16 and FC23 multi-register round trips.
Refs #741
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
The recursive RenderNode rendered every row of an expanded device, and built a
fresh context-menu list + closures per node per render. RawTreeRows flattens the
expanded tree (explicit stack) into rows rendered by one Virtualize; each level
shows 500 children, then a 'Show more' row. RawNodeMenuCache keys menus weakly
on the node instance and rebuilds on a picker-mode or Enabled change.
Refs #745
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
Review r0 of Integration-W3:
- I1: the effective FC16 cap is min(configured maxRegistersPerWrite, 123). The
driver trusts a larger configured cap and sends an over-ceiling FC16 (past 127
registers its byte-count byte wraps), which every conformant device refuses, so
such an array is never advertised writable.
- I2: a BitInRegister[] write reads its span first in one unchunked FC03, so it is
also bounded by min(configured maxRegistersPerRead, 125) (absent/0 = 125).
- m2: every span (registers and coils) must end at or before address 65535.
- m1: the String-node wording says it converts only when the text parses, which
the server cannot promise (DateTime never converts).
- m4: the parity device now enforces FC01/02 2000, FC03/04 125 (or a smaller
device read limit), FC15 1968, FC16 123, FC23 121/125, the FC15/FC16 byte-count
field (exception 03) and the address-space end (exception 02).
Tests: parity rows for each ceiling/cap/span boundary against the real write
path, and Core.Abstractions row tests. The configured-cap test that asserted a
cap of 200 lifts the ceiling now asserts the opposite.
Refs #741
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
R04:P2 - every analysis derives from one process-wide bound base compilation via
ReplaceSyntaxTree (the scoped service keeps its current compilation); every entry point
takes the request token, the endpoints bind RequestAborted and answer 499 on abort, and
the Monaco providers abort their fetch when Monaco cancels the request.
R04:P3 - the catalog snapshot carries a sorted, de-duplicated string[] (binary-search
prefix completion) and an Ordinal path->info dictionary, built once per snapshot.
R04:S1 - Diagnose emits an OTSCRIPT_SETVTAG_INERT warning for each ctx.SetVirtualTag
call. It is a warning, so editor-accepts == publish-accepts still holds.
Refs #745
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT